#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements.  See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License.  You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

"""Pipeline options obtained from command line parsing."""

# pytype: skip-file

import argparse
import difflib
import json
import logging
import os
from typing import Any
from typing import Callable
from typing import Dict
from typing import List
from typing import Optional
from typing import Sequence
from typing import Type
from typing import TypeVar

import apache_beam as beam
from apache_beam.options.value_provider import RuntimeValueProvider
from apache_beam.options.value_provider import StaticValueProvider
from apache_beam.options.value_provider import ValueProvider
from apache_beam.transforms.display import HasDisplayData
from apache_beam.utils import logger
from apache_beam.utils import proto_utils

__all__ = [
    'PipelineOptions',
    'StandardOptions',
    'TypeOptions',
    'DirectOptions',
    'GoogleCloudOptions',
    'AzureOptions',
    'HadoopFileSystemOptions',
    'WorkerOptions',
    'DebugOptions',
    'ProfilingOptions',
    'SetupOptions',
    'TestOptions',
    'S3Options'
]

PipelineOptionsT = TypeVar('PipelineOptionsT', bound='PipelineOptions')

_LOGGER = logging.getLogger(__name__)

# Map defined with option names to flag names for boolean options
# that have a destination(dest) in parser.add_argument() different
# from the flag name and whose default value is `None`.
_FLAG_THAT_SETS_FALSE_VALUE = {'use_public_ips': 'no_use_public_ips'}
# Set of options which should not be overriden when applying options from a
# different language. This is relevant when using x-lang transforms where the
# expansion service is started up with some pipeline options, and will
# impact which options are passed in to expanded transforms' expand functions.
_NON_OVERIDABLE_XLANG_OPTIONS = ['runner', 'experiments']


def _static_value_provider_of(value_type):
  """Helper function to plug a ValueProvider into argparse.

  Args:
    value_type: the type of the value. Since the type param of argparse's
                add_argument will always be ValueProvider, we need to
                preserve the type of the actual value.
  Returns:
    A partially constructed StaticValueProvider in the form of a function.

  """
  def _f(value):
    _f.__name__ = value_type.__name__
    return StaticValueProvider(value_type, value)

  return _f


class _BeamArgumentParser(argparse.ArgumentParser):
  """An ArgumentParser that supports ValueProvider options.

  Example Usage::

    class TemplateUserOptions(PipelineOptions):
      @classmethod

      def _add_argparse_args(cls, parser):
        parser.add_value_provider_argument('--vp_arg1', default='start')
        parser.add_value_provider_argument('--vp_arg2')
        parser.add_argument('--non_vp_arg')

  """
  def add_value_provider_argument(self, *args, **kwargs):
    """ValueProvider arguments can be either of type keyword or positional.
    At runtime, even positional arguments will need to be supplied in the
    key/value form.
    """
    # Extract the option name from positional argument ['pos_arg']
    assert args and len(args[0]) >= 1
    if args[0][0] != '-':
      option_name = args[0]
      if kwargs.get('nargs') is None:  # make them optionally templated
        kwargs['nargs'] = '?'
    else:
      # or keyword arguments like [--kw_arg, -k, -w] or [--kw-arg]
      option_name = [i.replace('--', '') for i in args if i[:2] == '--'][0]

    # reassign the type to make room for using
    # StaticValueProvider as the type for add_argument
    value_type = kwargs.get('type') or str
    kwargs['type'] = _static_value_provider_of(value_type)

    # reassign default to default_value to make room for using
    # RuntimeValueProvider as the default for add_argument
    default_value = kwargs.get('default')
    kwargs['default'] = RuntimeValueProvider(
        option_name=option_name,
        value_type=value_type,
        default_value=default_value)

    # have add_argument do most of the work
    self.add_argument(*args, **kwargs)

  # The argparse package by default tries to autocomplete option names. This
  # results in an "ambiguous option" error from argparse when an unknown option
  # matching multiple known ones are used. This suppresses that behavior.
  def error(self, message):
    if message.startswith('ambiguous option: '):
      return
    super().error(message)


class _DictUnionAction(argparse.Action):
  """
  argparse Action take union of json loads values. If a key is specified in more
  than one of the values, the last value takes precedence.
  """
  def __call__(self, parser, namespace, values, option_string=None):
    if not hasattr(namespace, self.dest) or getattr(namespace,
                                                    self.dest) is None:
      setattr(namespace, self.dest, {})
    getattr(namespace, self.dest).update(values)


class _GcsCustomAuditEntriesAction(argparse.Action):
  """
  Argparse Action for GCS custom audit entries.

  According to Google Cloud Storage audit logging documentation
  (https://cloud.google.com/storage/docs/audit-logging#add-custom-metadata), the
  following limitations apply:
  - keys must be 64 characters or less,
  - values 1,200 characters or less, and
  - a maximum of four custom metadata entries are permitted per request.
  """
  MAX_KEY_LENGTH = 64
  MAX_VALUE_LENGTH = 1200
  MAX_ENTRIES = 4
  GCS_AUDIT_PREFIX = 'x-goog-custom-audit-'

  def _exceed_entry_limit(self):
    job_audit_entry = _GcsCustomAuditEntriesAction.GCS_AUDIT_PREFIX + 'job'
    if job_audit_entry in self._custom_audit_entries:
      return len(
          self._custom_audit_entries) > _GcsCustomAuditEntriesAction.MAX_ENTRIES
    else:
      return len(self._custom_audit_entries) > (
          _GcsCustomAuditEntriesAction.MAX_ENTRIES - 1)

  def _add_entry(self, key, value):
    if len(key) > _GcsCustomAuditEntriesAction.MAX_KEY_LENGTH:
      raise argparse.ArgumentError(
          None,
          "The key '%s' in GCS custom audit entries exceeds the %d-character limit."  # pylint: disable=line-too-long
          % (key, _GcsCustomAuditEntriesAction.MAX_KEY_LENGTH))

    if len(value) > _GcsCustomAuditEntriesAction.MAX_VALUE_LENGTH:
      raise argparse.ArgumentError(
          None,
          "The value '%s' in GCS custom audit entries exceeds the %d-character limit."  # pylint: disable=line-too-long
          % (value, _GcsCustomAuditEntriesAction.MAX_VALUE_LENGTH))

    if key.startswith(_GcsCustomAuditEntriesAction.GCS_AUDIT_PREFIX):
      self._custom_audit_entries[key] = value
    else:
      self._custom_audit_entries[_GcsCustomAuditEntriesAction.GCS_AUDIT_PREFIX +
                                 key] = value

  def __call__(self, parser, namespace, values, option_string=None):
    if not hasattr(namespace, self.dest) or getattr(namespace,
                                                    self.dest) is None:
      setattr(namespace, self.dest, {})
      self._custom_audit_entries = getattr(namespace, self.dest)

    if option_string == '--gcs_custom_audit_entries':
      # in the format of {"key": "value"}
      assert (isinstance(values, str))
      sub_entries = json.loads(values)
      for key, value in sub_entries.items():
        self._add_entry(key, value)
    else:  # option_string == '--gcs_custom_audit_entry'
      # in the format of 'key=value'
      assert (isinstance(values, str))
      parts = values.split('=', 1)
      key = parts[0]
      value = parts[1] if len(parts) > 1 else ''
      self._add_entry(key, value)

    if self._exceed_entry_limit():
      raise argparse.ArgumentError(
          None,
          "The maximum allowed number of GCS custom audit entries (including the default x-goo-custom-audit-job) is %d."  # pylint: disable=line-too-long
          % _GcsCustomAuditEntriesAction.MAX_ENTRIES)


class _CommaSeparatedListAction(argparse.Action):
  """
  Argparse Action that splits comma-separated values and appends them to
  a list. This allows options like --experiments=abc,def to be treated
  as separate experiments 'abc' and 'def', similar to how Java SDK handles
  them.

  If there are key=value experiments in a raw argument, the remaining part of
  the argument are treated as values and won't split further. For example:
  'abc,def,master_key=k1=v1,k2=v2' becomes
  ['abc', 'def', 'master_key=k1=v1,k2=v2'].
  """
  def __call__(self, parser, namespace, values, option_string=None):
    if not hasattr(namespace, self.dest) or getattr(namespace,
                                                    self.dest) is None:
      setattr(namespace, self.dest, [])

    # Split comma-separated values and extend the list
    if isinstance(values, str):
      # Smart splitting: only split at commas that are not part of
      # key=value pairs
      split_values = self._smart_split(values)
      getattr(namespace, self.dest).extend(split_values)
    else:
      # If values is not a string, just append it
      getattr(namespace, self.dest).append(values)

  def _smart_split(self, values):
    """Split comma-separated values, but preserve commas within
    key=value pairs."""
    result = []
    current = []
    equals_depth = 0

    i = 0
    while i < len(values):
      char = values[i]

      if char == '=':
        equals_depth += 1
        current.append(char)
      elif char == ',' and equals_depth <= 1:
        # This comma is a top-level separator (not inside a complex value)
        if current:
          result.append(''.join(current).strip())
          current = []
          equals_depth = 0
      elif char == ',' and equals_depth > 1:
        # This comma is inside a complex value, keep it
        current.append(char)
      elif char == ' ' and not current:
        # Skip leading spaces
        pass
      else:
        current.append(char)

      i += 1

    # Add the last item
    if current:
      result.append(''.join(current).strip())

    return [v for v in result if v]  # Filter out empty values


class PipelineOptions(HasDisplayData):
  # Set of options which should not be overriden when pipeline options are
  # being merged (see from_runner_api). This primarily comes up when expanding
  # the Python expansion service

  """This class and subclasses are used as containers for command line options.

  These classes are wrappers over the standard argparse Python module
  (see https://docs.python.org/3/library/argparse.html).  To define one option
  or a group of options, create a subclass from PipelineOptions.

  Example Usage::

    class XyzOptions(PipelineOptions):

      @classmethod
      def _add_argparse_args(cls, parser):
        parser.add_argument('--abc', default='start')
        parser.add_argument('--xyz', default='end')

  The arguments for the add_argument() method are exactly the ones
  described in the argparse public documentation.

  Pipeline objects require an options object during initialization.
  This is obtained simply by initializing an options class as defined above.

  Example Usage::

    p = Pipeline(options=XyzOptions())
    if p.options.xyz == 'end':
      raise ValueError('Option xyz has an invalid value.')

  Instances of PipelineOptions or any of its subclass have access to values
  defined by other PipelineOption subclasses (see get_all_options()), and
  can be converted to an instance of another PipelineOptions subclass
  (see view_as()). All views share the underlying data structure that stores
  option key-value pairs.

  By default the options classes will use command line arguments to initialize
  the options.
  """
  def __init__(self, flags: Optional[Sequence[str]] = None, **kwargs) -> None:
    """Initialize an options class.

    The initializer will traverse all subclasses, add all their argparse
    arguments and then parse the command line specified by flags or by default
    the one obtained from sys.argv.

    The subclasses of PipelineOptions do not need to redefine __init__.

    Args:
      flags: An iterable of command line arguments to be used. If not specified
        then sys.argv will be used as input for parsing arguments.

      **kwargs: Add overrides for arguments passed in flags. For overrides
                of arguments, please pass the `option names` instead of
                flag names.
                Option names: These are defined as dest in the
                parser.add_argument() for each flag. Passing flags
                like {no_use_public_ips: True}, for which the dest is
                defined to a different flag name in the parser,
                would be discarded. Instead, pass the dest of
                the flag (dest of no_use_public_ips is use_public_ips).
    """
    # Initializing logging configuration in case the user did not set it up.
    logging.basicConfig()

    # self._flags stores a list of not yet parsed arguments, typically,
    # command-line flags. This list is shared across different views.
    # See: view_as().
    if isinstance(flags, str):
      # Unfortunately a single str passes the Iterable[str] test, as it is
      # an iterable of single characters.  This is almost certainly not the
      # intent...
      raise ValueError(
          "Flags must be an iterable of of strings, not a single string.")
    self._flags = flags

    # Build parser that will parse options recognized by the [sub]class of
    # PipelineOptions whose object is being instantiated.
    parser = _BeamArgumentParser(allow_abbrev=False)
    for cls in type(self).mro():
      if cls == PipelineOptions:
        break
      elif '_add_argparse_args' in cls.__dict__:
        cls._add_argparse_args(parser)  # type: ignore

    # The _visible_options attribute will contain options that were recognized
    # by the parser.
    self._visible_options, _ = parser.parse_known_args(flags)

    # self._all_options is initialized with overrides to flag values,
    # provided in kwargs, and will store key-value pairs for options recognized
    # by current PipelineOptions [sub]class and its views that may be created.
    # See: view_as().
    # This dictionary is shared across different views, and is lazily updated
    # as each new views are created.
    # Users access this dictionary store via __getattr__ / __setattr__ methods.
    self._all_options = kwargs

    # Initialize values of keys defined by this class.
    for option_name in self._visible_option_list():
      # Note that options specified in kwargs will not be overwritten.
      if option_name not in self._all_options:
        self._all_options[option_name] = getattr(
            self._visible_options, option_name)

  def __getstate__(self):
    # The impersonate_service_account option must be used only at submission of
    # a Beam job. However, Beam IOs might store pipeline options
    # within transform implementation that becomes serialized in RunnerAPI,
    # causing this option to be inadvertently used at runtime.
    # This serialization hook removes it.
    if self.view_as(GoogleCloudOptions).impersonate_service_account:
      dict_copy = dict(self.__dict__)
      dict_copy['_all_options'] = dict(dict_copy['_all_options'])
      dict_copy['_all_options']['impersonate_service_account'] = None
      return dict_copy
    else:
      return self.__dict__

  @classmethod
  def _add_argparse_args(cls, parser: _BeamArgumentParser) -> None:
    # Override this in subclasses to provide options.
    pass

  @classmethod
  def from_dictionary(cls, options):
    """Returns a PipelineOptions from a dictionary of arguments.

    Args:
      options: Dictionary of argument value pairs.

    Returns:
      A PipelineOptions object representing the given arguments.
    """
    flags = []
    for k, v in options.items():
      # Note: If a boolean flag is True in the dictionary,
      # implicitly the method assumes the boolean flag is
      # specified as a command line argument. If the
      # boolean flag is False, this method simply discards them.
      # Eg: {no_auth: True} is similar to python your_file.py --no_auth
      # {no_auth: False} is similar to python your_file.py.
      if isinstance(v, bool):
        if v:
          flags.append('--%s' % k)
        elif k in _FLAG_THAT_SETS_FALSE_VALUE:
          # Capture overriding flags, which have a different dest
          # from the flag name defined in the parser.add_argument
          # Eg: no_use_public_ips, which has the dest=use_public_ips
          # different from flag name
          flag_that_disables_the_option = (_FLAG_THAT_SETS_FALSE_VALUE[k])
          flags.append('--%s' % flag_that_disables_the_option)
      elif isinstance(v, list):
        for i in v:
          flags.append('--%s=%s' % (k, i))
      elif isinstance(v, dict):
        flags.append('--%s=%s' % (k, json.dumps(v)))
      elif v is None:
        # Don't process None type args here, they will be treated
        # as strings when parsed by BeamArgumentParser..
        logging.warning('Not setting flag with value None: %s', k)
      else:
        flags.append('--%s=%s' % (k, v))

    return cls(flags)

  @staticmethod
  def _warn_on_unknown_options(unknown_args, parser):
    if not unknown_args:
      return

    all_known_options = [
        opt for action in parser._actions for opt in action.option_strings
    ]

    for arg in unknown_args:
      msg = f"Unparseable argument: {arg}"
      if arg.startswith('--'):
        arg_name = arg.split('=', 1)[0]
        suggestions = difflib.get_close_matches(arg_name, all_known_options)
        if suggestions:
          msg += f". Did you mean '{suggestions[0]}'?'"
      logger.log_first_n(logging.WARN, msg, key="message")

  def get_all_options(
      self,
      drop_default=False,
      add_extra_args_fn: Optional[Callable[[_BeamArgumentParser], None]] = None,
      retain_unknown_options=False,
      display_warnings=False) -> Dict[str, Any]:
    """Returns a dictionary of all defined arguments.

    Returns a dictionary of all defined arguments (arguments that are defined in
    any subclass of PipelineOptions) into a dictionary.

    Args:
      drop_default: If set to true, options that are equal to their default
        values, are not returned as part of the result dictionary.
      add_extra_args_fn: Callback to populate additional arguments, can be used
        by runner to supply otherwise unknown args.
      retain_unknown_options: If set to true, options not recognized by any
        known pipeline options class will still be included in the result. If
        set to false, they will be discarded.

    Returns:
      Dictionary of all args and values.
    """

    # TODO(https://github.com/apache/beam/issues/18197): PipelineOption
    # sub-classes in the main session might be repeated. Pick last unique
    # instance of each subclass to avoid conflicts.
    subset = {}
    parser = _BeamArgumentParser(allow_abbrev=False)
    for cls in PipelineOptions.__subclasses__():
      subset.setdefault(str(cls), cls)
    for cls in subset.values():
      cls._add_argparse_args(parser)  # pylint: disable=protected-access
    if add_extra_args_fn:
      add_extra_args_fn(parser)

    known_args, unknown_args = parser.parse_known_args(self._flags)

    if display_warnings:
      self._warn_on_unknown_options(unknown_args, parser)

    if retain_unknown_options:
      seen = set()

      def add_new_arg(arg, **kwargs):
        if arg not in seen:
          parser.add_argument(arg, **kwargs)
        seen.add(arg)

      i = 0
      while i < len(unknown_args):
        # End of argument parsing.
        if unknown_args[i] == '--':
          break
        # Treat all unary flags as booleans, and all binary argument values as
        # strings.
        if not unknown_args[i].startswith('-'):
          i += 1
          continue
        if i + 1 >= len(unknown_args) or unknown_args[i + 1].startswith('-'):
          split = unknown_args[i].split('=', 1)
          if len(split) == 1:
            add_new_arg(unknown_args[i], action='store_true')
          else:
            add_new_arg(split[0], type=str)
          i += 1
        elif unknown_args[i].startswith('--'):
          add_new_arg(unknown_args[i], type=str)
          i += 2
        else:
          # skip all binary flags used with '-' and not '--'.
          # ex: using -f instead of --f (or --flexrs_goal) will prevent
          # argument validation before job submission and can be incorrectly
          # submitted to job.
          _LOGGER.warning(
              "Discarding flag %s, single dash flags are not allowed.",
              unknown_args[i])
          i += 2
          continue
      parsed_args, _ = parser.parse_known_args(self._flags)
    else:
      if unknown_args:
        _LOGGER.warning("Discarding unparseable args: %s", unknown_args)
      parsed_args = known_args
    result = vars(parsed_args)

    overrides = self._all_options.copy()
    # Apply the overrides if any
    for k in list(result):
      overrides.pop(k, None)
      if k in self._all_options:
        result[k] = self._all_options[k]
      if (drop_default and parser.get_default(k) == result[k] and
          not isinstance(parser.get_default(k), ValueProvider)):
        del result[k]

    if overrides:
      if retain_unknown_options:
        result.update(overrides)
      else:
        _LOGGER.warning("Discarding invalid overrides: %s", overrides)

    return result

  def to_runner_api(self):
    def to_struct_value(o):
      if isinstance(o, (bool, int, str)):
        return o
      elif isinstance(o, (tuple, list)):
        return [to_struct_value(e) for e in o]
      elif isinstance(o, dict):
        return {str(k): to_struct_value(v) for k, v in o.items()}
      else:
        return str(o)  # Best effort.

    return proto_utils.pack_Struct(
        **{
            f'beam:option:{k}:v1': to_struct_value(v)
            for (k, v) in self.get_all_options(
                drop_default=True, retain_unknown_options=True).items()
            if v is not None
        })

  @classmethod
  def from_runner_api(cls, proto_options, original_options=None):
    def from_urn(key):
      assert key.startswith('beam:option:')
      assert key.endswith(':v1')
      return key[12:-3]

    parsed = {from_urn(key): value for (key, value) in proto_options.items()}
    if original_options is None:
      return cls(**parsed)
    for (key, value) in parsed.items():
      if value and key not in _NON_OVERIDABLE_XLANG_OPTIONS:
        original_options._all_options[key] = value
    return original_options

  def display_data(self):
    return self.get_all_options(drop_default=True, retain_unknown_options=True)

  def view_as(self, cls: Type[PipelineOptionsT]) -> PipelineOptionsT:
    """Returns a view of current object as provided PipelineOption subclass.

    Example Usage::

      options = PipelineOptions(['--runner', 'Direct', '--streaming'])
      standard_options = options.view_as(StandardOptions)
      if standard_options.streaming:
        # ... start a streaming job ...

    Note that options objects may have multiple views, and modifications
    of values in any view-object will apply to current object and other
    view-objects.

    Args:
      cls: PipelineOptions class or any of its subclasses.

    Returns:
      An instance of cls that is initialized using options contained in current
      object.

    """
    view = cls(self._flags)

    for option_name in view._visible_option_list():
      # Initialize values of keys defined by a cls.
      #
      # Note that we do initialization only once per key to make sure that
      # values in _all_options dict are not-recreated with each new view.
      # This is important to make sure that values of multi-options keys are
      # backed by the same list across multiple views, and that any overrides of
      # pipeline options already stored in _all_options are preserved.
      if option_name not in self._all_options:
        self._all_options[option_name] = getattr(
            view._visible_options, option_name)
    # Note that views will still store _all_options of the source object.
    view._all_options = self._all_options
    return view

  def _visible_option_list(self) -> List[str]:
    return sorted(
        option for option in dir(self._visible_options) if option[0] != '_')

  def __dir__(self) -> List[str]:
    return sorted(
        dir(type(self)) + list(self.__dict__) + self._visible_option_list())

  def __getattr__(self, name):
    # Special methods which may be accessed before the object is
    # fully constructed (e.g. in unpickling).
    if name[:2] == name[-2:] == '__':
      return object.__getattribute__(self, name)
    elif name in self._visible_option_list():
      return self._all_options[name]
    else:
      raise AttributeError(
          "'%s' object has no attribute '%s'" % (type(self).__name__, name))

  def __setattr__(self, name, value):
    if name in ('_flags', '_all_options', '_visible_options'):
      super().__setattr__(name, value)
    elif name in self._visible_option_list():
      self._all_options[name] = value
    else:
      raise AttributeError(
          "'%s' object has no attribute '%s'" % (type(self).__name__, name))

  def __str__(self):
    return '%s(%s)' % (
        type(self).__name__,
        ', '.join(
            '%s=%s' % (option, getattr(self, option))
            for option in self._visible_option_list()))


class StandardOptions(PipelineOptions):

  DEFAULT_RUNNER = 'DirectRunner'

  ALL_KNOWN_RUNNERS = (
      'apache_beam.runners.dataflow.dataflow_runner.DataflowRunner',
      'apache_beam.runners.direct.direct_runner.BundleBasedDirectRunner',
      'apache_beam.runners.direct.direct_runner.DirectRunner',
      'apache_beam.runners.direct.direct_runner.SwitchingDirectRunner',
      'apache_beam.runners.interactive.interactive_runner.InteractiveRunner',
      'apache_beam.runners.portability.flink_runner.FlinkRunner',
      'apache_beam.runners.portability.fn_api_runner.FnApiRunner',
      'apache_beam.runners.portability.portable_runner.PortableRunner',
      'apache_beam.runners.portability.prism_runner.PrismRunner',
      'apache_beam.runners.portability.spark_runner.SparkRunner',
      'apache_beam.runners.test.TestDirectRunner',
      'apache_beam.runners.test.TestDataflowRunner',
  )

  LOCAL_RUNNERS = (
      'BundleBasedDirectRunner',
      'DirectRunner',
      'SwitchingDirectRunner',
      'FnApiRunner',
      'PrismRunner',
      'TestDirectRunner',
  )

  KNOWN_RUNNER_NAMES = [path.split('.')[-1] for path in ALL_KNOWN_RUNNERS]

  @classmethod
  def _add_argparse_args(cls, parser):
    parser.add_argument(
        '--runner',
        help=(
            'Pipeline runner used to execute the workflow. Valid values are '
            'one of %s, or the fully qualified name of a PipelineRunner '
            'subclass. If unspecified, defaults to %s.' %
            (', '.join(cls.KNOWN_RUNNER_NAMES), cls.DEFAULT_RUNNER)))
    # Whether to enable streaming mode.
    parser.add_argument(
        '--streaming',
        default=False,
        action='store_true',
        help='Whether to enable streaming mode.')

    parser.add_argument(
        '--resource_hint',
        '--resource_hints',
        dest='resource_hints',
        action='append',
        default=[],
        help=(
            'Resource hint to set in the pipeline execution environment.'
            'Hints specified via this option override hints specified '
            'at transform level. Interpretation of hints is defined by '
            'Beam runners.'))

    parser.add_argument(
        '--auto_unique_labels',
        default=False,
        action='store_true',
        help='Whether to automatically generate unique transform labels '
        'for every transform. The default behavior is to raise an '
        'exception if a transform is created with a non-unique label. '
        'Using --auto_unique_labels could cause data loss when '
        'updating a pipeline or reloading the job state. '
        'This is not recommended for streaming jobs.')

    parser.add_argument(
        '--no_wait_until_finish',
        default=False,
        action='store_true',
        help='By default, the "with" statement waits for the job to '
        'complete. Set this flag to bypass this behavior and continue '
        'execution immediately')


class StreamingOptions(PipelineOptions):
  @classmethod
  def _add_argparse_args(cls, parser):
    parser.add_argument(
        '--update_compatibility_version',
        default=None,
        nargs='?',
        const=None,
        help='Attempt to produce a pipeline compatible with the given prior '
        'version of the Beam SDK. '
        'See for example, https://cloud.google.com/dataflow/docs/guides/'
        'updating-a-pipeline')


class CrossLanguageOptions(PipelineOptions):
  @staticmethod
  def _beam_services_from_enviroment():
    return json.loads(os.environ.get('BEAM_SERVICE_OVERRIDES') or '{}')

  @classmethod
  def _add_argparse_args(cls, parser):
    parser.add_argument(
        '--beam_services',
        type=lambda s: {
            **cls._beam_services_from_enviroment(), **json.loads(s)
        },
        default=cls._beam_services_from_enviroment(),
        help=(
            'For convenience, Beam provides the ability to automatically '
            'download and start various services (such as expansion services) '
            'used at pipeline construction and execution. These services are '
            'identified by gradle target. This option provides the ability to '
            'use pre-started services or non-default pre-existing artifacts to '
            'start the given service. '
            'Should be a json mapping of gradle build targets to pre-built '
            'artifacts (e.g. jar files) or expansion endpoints '
            '(e.g. host:port). Defaults to the value of BEAM_SERVICE_OVERRIDES '
            'from the environment.'))

    parser.add_argument(
        '--use_transform_service',
        default=False,
        action='store_true',
        help='Use the Docker-composed-based transform service when expanding '
        'cross-language transforms.')


def additional_option_ptransform_fn():
  beam.transforms.ptransform.ptransform_fn_typehints_enabled = True


# Optional type checks that aren't enabled by default.
additional_type_checks: Dict[str, Callable[[], None]] = {
    'ptransform_fn': additional_option_ptransform_fn,
}


def enable_all_additional_type_checks():
  """Same as passing --type_check_additional=all."""
  for f in additional_type_checks.values():
    f()


class TypeOptions(PipelineOptions):
  @classmethod
  def _add_argparse_args(cls, parser):
    # TODO(laolu): Add a type inferencing option here once implemented.
    parser.add_argument(
        '--type_check_strictness',
        default='DEFAULT_TO_ANY',
        choices=['ALL_REQUIRED', 'DEFAULT_TO_ANY'],
        help='The level of exhaustive manual type-hint '
        'annotation required')
    parser.add_argument(
        '--type_check_additional',
        default='',
        help='Comma separated list of additional type checking features to '
        'enable. Options: all, ptransform_fn. For details see:'
        'https://beam.apache.org/documentation/sdks/python-type-safety/')
    parser.add_argument(
        '--no_pipeline_type_check',
        dest='pipeline_type_check',
        action='store_false',
        help='Disable type checking at pipeline construction '
        'time')
    parser.add_argument(
        '--runtime_type_check',
        default=False,
        action='store_true',
        help='Enable type checking at pipeline execution '
        'time. NOTE: only supported with the '
        'DirectRunner')
    parser.add_argument(
        '--performance_runtime_type_check',
        default=False,
        action='store_true',
        help='Enable faster type checking via sampling at pipeline execution '
        'time. NOTE: only supported with portable runners '
        '(including the DirectRunner)')
    parser.add_argument(
        '--allow_non_deterministic_key_coders',
        default=False,
        action='store_true',
        help='Use non-deterministic coders (such as pickling) for key-grouping '
        'operations such as GroupByKey.  This is unsafe, as runners may group '
        'keys based on their encoded bytes, but is available for backwards '
        'compatibility. See BEAM-11719.')
    parser.add_argument(
        '--allow_unsafe_triggers',
        default=False,
        action='store_true',
        help='Allow the use of unsafe triggers. Unsafe triggers have the '
        'potential to cause data loss due to finishing and/or never having '
        'their condition met. Some operations, such as GroupByKey, disallow '
        'this. This exists for cases where such loss is acceptable and for '
        'backwards compatibility. See BEAM-9487.')
    parser.add_argument(
        '--force_cloudpickle_deterministic_coders',
        default=False,
        action='store_true',
        help=(
            'Force the use of cloudpickle-based deterministic coders '
            'instead of dill-based coders, even when '
            'update_compatibility_version  would normally trigger dill usage '
            'for backward compatibility. This flag overrides automatic coder '
            'selection to always use the modern cloudpickle serialization '
            ' path. Warning: May break pipeline update compatibility with '
            ' SDK versions prior to 2.68.0.'))

  def validate(self, unused_validator):
    errors = []
    if beam.version.__version__ >= '3':
      errors.append(
          'Update --type_check_additional default to include all '
          'available additional checks at Beam 3.0 release time.')
    keys = self.type_check_additional.split(',')

    for key in keys:
      if not key:
        continue
      elif key == 'all':
        enable_all_additional_type_checks()
      elif key in additional_type_checks:
        additional_type_checks[key]()
      else:
        errors.append('Unrecognized --type_check_additional feature: %s' % key)

    return errors


class DirectOptions(PipelineOptions):
  """DirectRunner-specific execution options."""
  @classmethod
  def _add_argparse_args(cls, parser):
    parser.add_argument(
        '--no_direct_runner_use_stacked_bundle',
        action='store_false',
        dest='direct_runner_use_stacked_bundle',
        help='DirectRunner uses stacked WindowedValues within a Bundle for '
        'memory optimization. Set --no_direct_runner_use_stacked_bundle to '
        'avoid it.')
    parser.add_argument(
        '--direct_runner_bundle_repeat',
        type=int,
        default=0,
        help='replay every bundle this many extra times, for profiling'
        'and debugging')
    parser.add_argument(
        '--direct_num_workers',
        type=int,
        default=1,
        help='number of parallel running workers.')
    parser.add_argument(
        '--direct_running_mode',
        default='in_memory',
        choices=['in_memory', 'multi_threading', 'multi_processing'],
        help='Workers running environment.')
    parser.add_argument(
        '--direct_embed_docker_python',
        default=False,
        action='store_true',
        dest='direct_embed_docker_python',
        help='DirectRunner uses the embedded Python environment when '
        'the default Python docker environment is specified.')
    parser.add_argument(
        '--direct_test_splits',
        default={},
        type=json.loads,
        help='Split test configuration of the json form '
        '{"step_name": {"timings": [...], "fractions": [...]}, ...} '
        'where step_name is the name of a step controlling the stage to which '
        'splits will be sent, timings is a list of floating-point times '
        '(in seconds) at which the split requests will be sent, and '
        'fractions is a corresponding list of floating points to use in the '
        'split requests themselves.')


class GoogleCloudOptions(PipelineOptions):
  """Google Cloud Dataflow service execution options."""

  BIGQUERY_API_SERVICE = 'bigquery.googleapis.com'
  COMPUTE_API_SERVICE = 'compute.googleapis.com'
  STORAGE_API_SERVICE = 'storage.googleapis.com'
  DATAFLOW_ENDPOINT = 'https://dataflow.googleapis.com'
  OAUTH_SCOPES = [
      'https://www.googleapis.com/auth/bigquery',
      'https://www.googleapis.com/auth/cloud-platform',
      'https://www.googleapis.com/auth/devstorage.full_control',
      'https://www.googleapis.com/auth/userinfo.email',
      'https://www.googleapis.com/auth/datastore',
      'https://www.googleapis.com/auth/spanner.admin',
      'https://www.googleapis.com/auth/spanner.data'
  ]

  @classmethod
  def _add_argparse_args(cls, parser):
    parser.add_argument(
        '--dataflow_endpoint',
        default=cls.DATAFLOW_ENDPOINT,
        help=(
            'The URL for the Dataflow API. If not set, the default public URL '
            'will be used.'))
    # Remote execution must check that this option is not None.
    parser.add_argument(
        '--project',
        default=None,
        help='Name of the Cloud project owning the Dataflow '
        'job.')
    # Remote execution must check that this option is not None.
    parser.add_argument(
        '--job_name', default=None, help='Name of the Cloud Dataflow job.')
    # Remote execution must check that this option is not None.
    parser.add_argument(
        '--staging_location',
        default=None,
        help='GCS path for staging code packages needed by '
        'workers.')
    # Remote execution must check that this option is not None.
    # If staging_location is not set, it defaults to temp_location.
    parser.add_argument(
        '--temp_location',
        default=None,
        help='GCS path for saving temporary workflow jobs.')
    # The Google Compute Engine region for creating Dataflow jobs. See
    # https://cloud.google.com/compute/docs/regions-zones/regions-zones for a
    # list of valid options.
    parser.add_argument(
        '--region',
        default=None,
        help='The Google Compute Engine region for creating '
        'Dataflow job.')
    parser.add_argument(
        '--service_account_email',
        default=None,
        help='Identity to run virtual machines as.')
    parser.add_argument(
        '--no_auth',
        dest='no_auth',
        action='store_true',
        default=False,
        help='Skips authorizing credentials with Google Cloud.')
    # Option to run templated pipelines
    parser.add_argument(
        '--template_location',
        default=None,
        help='Save job to specified local or GCS location.')
    parser.add_argument(
        '--label',
        '--labels',
        dest='labels',
        action='append',
        default=None,
        help='Labels to be applied to this Dataflow job. '
        'Labels are key value pairs separated by = '
        '(e.g. --label key=value) or '
        '(--labels=\'{ "key": "value", "mass": "1_3kg", "count": "3" }\').')
    parser.add_argument(
        '--update',
        default=False,
        action='store_true',
        help='Update an existing streaming Cloud Dataflow job. '
        'See https://cloud.google.com/dataflow/docs/guides/'
        'updating-a-pipeline')
    parser.add_argument(
        '--transform_name_mapping',
        default=None,
        type=json.loads,
        help='The transform mapping that maps the named '
        'transforms in your prior pipeline code to names '
        'in your replacement pipeline code.'
        'See https://cloud.google.com/dataflow/docs/guides/'
        'updating-a-pipeline')
    parser.add_argument(
        '--enable_streaming_engine',
        default=True,
        action='store_true',
        help='Deprecated. All Python streaming pipelines on Dataflow'
        'use Streaming Engine.')
    parser.add_argument(
        '--dataflow_kms_key',
        default=None,
        help='Set a Google Cloud KMS key name to be used in '
        'Dataflow state operations (GBK, Streaming).')
    parser.add_argument(
        '--create_from_snapshot',
        default=None,
        help='The snapshot from which the job should be created.')
    parser.add_argument(
        '--flexrs_goal',
        default=None,
        choices=['COST_OPTIMIZED', 'SPEED_OPTIMIZED'],
        help='Set the Flexible Resource Scheduling mode')
    parser.add_argument(
        '--dataflow_service_option',
        '--dataflow_service_options',
        dest='dataflow_service_options',
        action=_CommaSeparatedListAction,
        default=None,
        help=(
            'Options to configure the Dataflow service. These '
            'options decouple service side feature availability '
            'from the Apache Beam release cycle.'
            'Note: If set programmatically, must be set as a '
            'list of strings'))
    parser.add_argument(
        '--enable_hot_key_logging',
        default=False,
        action='store_true',
        help='When true, will enable the direct logging of any detected hot '
        'keys into Cloud Logging. Warning: this will log the literal key as an '
        'unobfuscated string.')
    parser.add_argument(
        '--enable_artifact_caching',
        default=False,
        action='store_true',
        help='When true, artifacts will be cached across job submissions in '
        'the GCS staging bucket')
    parser.add_argument(
        '--impersonate_service_account',
        default=None,
        help='All API requests will be made as the given service account or '
        'target service account in an impersonation delegation chain '
        'instead of the currently selected account. You can specify '
        'either a single service account as the impersonator, or a '
        'comma-separated list of service accounts to create an '
        'impersonation delegation chain.')
    parser.add_argument(
        '--gcp_oauth_scope',
        '--gcp_oauth_scopes',
        dest='gcp_oauth_scopes',
        action='append',
        default=cls.OAUTH_SCOPES,
        help=(
            'Controls the OAuth scopes that will be requested when creating '
            'GCP credentials. Note: If set programmatically, must be set as a '
            'list of strings'))
    parser.add_argument(
        '--enable_bucket_read_metric_counter',
        default=False,
        action='store_true',
        help='Create metrics reporting the approximate number of bytes read '
        'per bucket.')
    parser.add_argument(
        '--enable_bucket_write_metric_counter',
        default=False,
        action='store_true',
        help=
        'Create metrics reporting the approximate number of bytes written per '
        'bucket.')
    parser.add_argument(
        '--no_gcsio_throttling_counter',
        default=False,
        action='store_true',
        help='Throttling counter in GcsIO is enabled by default. Set '
        '--no_gcsio_throttling_counter to avoid it.')
    parser.add_argument(
        '--enable_gcsio_blob_generation',
        default=False,
        action='store_true',
        help='Use blob generation when mutating blobs in GCSIO to '
        'mitigate race conditions at the cost of more HTTP requests.')
    parser.add_argument(
        '--gcs_custom_audit_entry',
        '--gcs_custom_audit_entries',
        dest='gcs_custom_audit_entries',
        action=_GcsCustomAuditEntriesAction,
        default=None,
        help='Custom information to be attached to audit logs. '
        'Entries are key value pairs separated by = '
        '(e.g. --gcs_custom_audit_entry key=value) or a JSON string '
        '(e.g. --gcs_custom_audit_entries=\'{ "user": "test", "id": "12" }\').')

  def _create_default_gcs_bucket(self):
    try:
      from apache_beam.io.gcp import gcsio
    except ImportError:
      _LOGGER.warning('Unable to create default GCS bucket.')
      return None
    bucket = gcsio.get_or_create_default_gcs_bucket(self)
    if bucket:
      return 'gs://%s/' % bucket.id
    else:
      return None

  # Log warning if soft delete policy is enabled in a gcs bucket
  # that is specified in an argument.
  def _warn_if_soft_delete_policy_enabled(self, arg_name):
    # skip the check if it is in dry-run mode because the later step requires
    # internet connection to access GCS
    if self.view_as(TestOptions).dry_run:
      return

    gcs_path = getattr(self, arg_name, None)
    try:
      from apache_beam.io.gcp import gcsio
      if gcsio.GcsIO().is_soft_delete_enabled(gcs_path):
        logger.log_first_n(
            logging.WARN,
            "Bucket %s used as %s has soft-delete policy enabled."
            " To avoid being billed for unnecessary storage costs, turn"
            " off the soft delete feature on buckets that your Dataflow"
            " jobs use for temporary and staging storage. For more"
            " information, see"
            " https://cloud.google.com/storage/docs/use-soft-delete"
            "#remove-soft-delete-policy.",
            gcs_path,
            arg_name,
            n=1,
            key="message")
    except ImportError:
      _LOGGER.warning('Unable to check soft delete policy due to import error.')

  # If either temp or staging location has an issue, we use the valid one for
  # both locations. If both are bad we return an error.
  def _handle_temp_and_staging_locations(self, validator):
    temp_errors = validator.validate_gcs_path(self, 'temp_location')
    staging_errors = validator.validate_gcs_path(self, 'staging_location')
    if temp_errors and not staging_errors:
      setattr(self, 'temp_location', getattr(self, 'staging_location'))
      self._warn_if_soft_delete_policy_enabled('staging_location')
      return []
    elif staging_errors and not temp_errors:
      setattr(self, 'staging_location', getattr(self, 'temp_location'))
      self._warn_if_soft_delete_policy_enabled('temp_location')
      return []
    elif not staging_errors and not temp_errors:
      self._warn_if_soft_delete_policy_enabled('temp_location')
      self._warn_if_soft_delete_policy_enabled('staging_location')
      return []
    # Both staging and temp locations are bad, try to use default bucket.
    else:
      default_bucket = self._create_default_gcs_bucket()
      if default_bucket is None:
        temp_errors.extend(staging_errors)
        return temp_errors
      else:
        setattr(self, 'temp_location', default_bucket)
        setattr(self, 'staging_location', default_bucket)
        self._warn_if_soft_delete_policy_enabled('temp_location')
        self._warn_if_soft_delete_policy_enabled('staging_location')
        return []

  def validate(self, validator):
    errors = []
    if validator.is_service_runner():
      errors.extend(self._handle_temp_and_staging_locations(validator))
      errors.extend(validator.validate_cloud_options(self))

    if self.view_as(DebugOptions).dataflow_job_file:
      if self.view_as(GoogleCloudOptions).template_location:
        errors.append(
            '--dataflow_job_file and --template_location '
            'are mutually exclusive.')

    # Validate that dataflow_service_options is a list
    if self.dataflow_service_options:
      errors.extend(
          validator.validate_repeatable_argument_passed_as_list(
              self, 'dataflow_service_options'))

    return errors

  def get_cloud_profiler_service_name(self):
    _ENABLE_GOOGLE_CLOUD_PROFILER = 'enable_google_cloud_profiler'
    if self.dataflow_service_options:
      if _ENABLE_GOOGLE_CLOUD_PROFILER in self.dataflow_service_options:
        return os.environ["JOB_NAME"]
      for option_name in self.dataflow_service_options:
        if option_name.startswith(_ENABLE_GOOGLE_CLOUD_PROFILER + '='):
          return option_name.split('=', 1)[1]

    experiments = self.view_as(DebugOptions).experiments or []
    if _ENABLE_GOOGLE_CLOUD_PROFILER in experiments:
      return os.environ["JOB_NAME"]

    return None


class AzureOptions(PipelineOptions):
  """Azure Blob Storage options."""
  @classmethod
  def _add_argparse_args(cls, parser):
    parser.add_argument(
        '--azure_connection_string',
        default=None,
        help='Connection string of the Azure Blob Storage Account.')
    parser.add_argument(
        '--blob_service_endpoint',
        default=None,
        help='URL of the Azure Blob Storage Account.')
    parser.add_argument(
        '--azure_managed_identity_client_id',
        default=None,
        help='Client ID of a user-assigned managed identity.')

  def validate(self, validator):
    errors = []
    if self.azure_connection_string:
      if self.blob_service_endpoint:
        errors.append(
            '--azure_connection_string and '
            '--blob_service_endpoint are mutually exclusive.')

    return errors


class HadoopFileSystemOptions(PipelineOptions):
  """``HadoopFileSystem`` connection options."""
  @classmethod
  def _add_argparse_args(cls, parser):
    parser.add_argument(
        '--hdfs_host',
        default=None,
        help=('Hostname or address of the HDFS namenode.'))
    parser.add_argument(
        '--hdfs_port', default=None, help=('Port of the HDFS namenode.'))
    parser.add_argument(
        '--hdfs_user', default=None, help=('HDFS username to use.'))
    parser.add_argument(
        '--hdfs_full_urls',
        default=False,
        action='store_true',
        help=(
            'If set, URLs will be parsed as "hdfs://server/path/...", instead '
            'of "hdfs://path/...". The "server" part will be unused (use '
            '--hdfs_host and --hdfs_port).'))

  def validate(self, validator):
    errors = []
    errors.extend(validator.validate_optional_argument_positive(self, 'port'))
    return errors


# Command line options controlling the worker pool configuration.
# TODO(silviuc): Update description when autoscaling options are in.
class WorkerOptions(PipelineOptions):
  """Worker pool configuration options."""
  @classmethod
  def _add_argparse_args(cls, parser):
    parser.add_argument(
        '--num_workers',
        type=int,
        default=None,
        help=(
            'Number of workers to use when executing the Dataflow job. If not '
            'set, the Dataflow service will use a reasonable default.'))
    parser.add_argument(
        '--max_num_workers',
        type=int,
        default=None,
        help=(
            'Maximum number of workers to use when executing the Dataflow job.'
        ))
    parser.add_argument(
        '--autoscaling_algorithm',
        type=str,
        choices=['NONE', 'THROUGHPUT_BASED'],
        default=None,  # Meaning unset, distinct from 'NONE' meaning don't scale
        help=('If and how to autoscale the workerpool.'))
    parser.add_argument(
        '--worker_machine_type',
        '--machine_type',
        dest='machine_type',
        default=None,
        help=(
            'Machine type to create Dataflow worker VMs as. See '
            'https://cloud.google.com/compute/docs/machine-types '
            'for a list of valid options. If not set, '
            'the Dataflow service will choose a reasonable '
            'default.'))
    parser.add_argument(
        '--disk_size_gb',
        type=int,
        default=None,
        help=(
            'Remote worker disk size, in gigabytes, or 0 to use the default '
            'size. If not set, the Dataflow service will use a reasonable '
            'default.'))
    parser.add_argument(
        '--worker_disk_type',
        '--disk_type',
        dest='disk_type',
        default=None,
        help=('Specifies what type of persistent disk should be used.'))
    parser.add_argument(
        '--worker_region',
        default=None,
        help=(
            'The Compute Engine region (https://cloud.google.com/compute/docs/'
            'regions-zones/regions-zones) in which worker processing should '
            'occur, e.g. "us-west1". Mutually exclusive with worker_zone. If '
            'neither worker_region nor worker_zone is specified, default to '
            'same value as --region.'))
    parser.add_argument(
        '--worker_zone',
        default=None,
        help=(
            'The Compute Engine zone (https://cloud.google.com/compute/docs/'
            'regions-zones/regions-zones) in which worker processing should '
            'occur, e.g. "us-west1-a". Mutually exclusive with worker_region. '
            'If neither worker_region nor worker_zone is specified, the '
            'Dataflow service will choose a zone in --region based on '
            'available capacity.'))
    parser.add_argument(
        '--zone',
        default=None,
        help=(
            'GCE availability zone for launching workers. Default is up to the '
            'Dataflow service. This flag is deprecated, and will be replaced '
            'by worker_zone.'))
    parser.add_argument(
        '--network',
        default=None,
        help=(
            'GCE network for launching workers. Default is up to the Dataflow '
            'service.'))
    parser.add_argument(
        '--subnetwork',
        default=None,
        help=(
            'GCE subnetwork for launching workers. Default is up to the '
            'Dataflow service. Expected format is '
            'regions/REGION/subnetworks/SUBNETWORK or the fully qualified '
            'subnetwork name. For more information, see '
            'https://cloud.google.com/compute/docs/vpc/'))
    parser.add_argument(
        '--worker_harness_container_image',
        default=None,
        help=(
            'Docker registry location of container image to use for the '
            'worker harness. If not set, an appropriate approved Google Cloud '
            'Dataflow image will be used based on the version of the '
            'SDK. Note: This flag is deprecated and only supports '
            'approved Google Cloud Dataflow container images. To provide a '
            'custom container image, use sdk_container_image instead.'))
    parser.add_argument(
        '--sdk_container_image',
        default=None,
        help=(
            'Docker registry location of container image to use for the '
            'worker harness. If not set, an appropriate approved Google Cloud '
            'Dataflow image will be used based on the version of the '
            'SDK. If set for a non-portable pipeline, only official '
            'Google Cloud Dataflow container images may be used here.'))
    parser.add_argument(
        '--sdk_harness_container_image_overrides',
        action='append',
        default=None,
        help=(
            'Overrides for SDK harness container images. Could be for the '
            'local SDK or for a remote SDK that pipeline has to support due '
            'to a cross-language transform. Each entry consist of two values '
            'separated by a comma where first value gives a regex to '
            'identify the container image to override and the second value '
            'gives the replacement container image.'))
    parser.add_argument(
        '--default_sdk_harness_log_level',
        default=None,
        help=(
            'Controls the default log level of all loggers without a log level '
            'override. Values can be either a labeled level or a number '
            '(See https://docs.python.org/3/library/logging.html#levels). '
            'Default log level is INFO.'))
    parser.add_argument(
        '--sdk_harness_log_level_overrides',
        type=json.loads,
        action=_DictUnionAction,
        default=None,
        help=(
            'Controls the log levels for specifically named loggers. The '
            'expected format is a json string: \'{"module":"log_level",...}\'. '
            'For example, by specifying the value \'{"a.b.c":"DEBUG"}\', '
            'the logger underneath the module "a.b.c" will be configured to '
            'output logs at the DEBUG level. Similarly, by specifying the '
            'value \'{"a.b.c":"WARNING"}\' all loggers underneath the "a.b.c" '
            'module will be configured to output logs at the WARNING level. '
            'Also, note that when multiple overrides are specified, the exact '
            'name followed by the closest parent takes precedence.'))
    parser.add_argument(
        '--use_public_ips',
        default=None,
        action='store_true',
        help='Whether to assign public IP addresses to the worker VMs.')
    parser.add_argument(
        '--no_use_public_ips',
        dest='use_public_ips',
        default=None,
        action='store_false',
        help='Whether to assign only private IP addresses to the worker VMs.')
    parser.add_argument(
        '--min_cpu_platform',
        dest='min_cpu_platform',
        type=str,
        help='GCE minimum CPU platform. Default is determined by GCP.')
    parser.add_argument(
        '--max_cache_memory_usage_mb',
        dest='max_cache_memory_usage_mb',
        type=int,
        default=0,
        help=(
            'Size of the SDK Harness cache to store user state and side '
            'inputs in MB. The cache is disabled by default. Increasing '
            'cache size might improve performance of some pipelines, such as '
            'pipelines that use iterable side input views, but can '
            'lead to an increase in memory consumption and OOM errors if '
            'workers are not appropriately provisioned. '
            'Using the cache might decrease performance pipelines using '
            'materialized side inputs. '
            'If the cache is full, least '
            'recently used elements will be evicted. This cache is per '
            'each SDK Harness instance. SDK Harness is a component '
            'responsible for executing the user code and communicating with '
            'the runner. Depending on the runner, there may be more than one '
            'SDK Harness process running on the same worker node.'))
    parser.add_argument(
        '--element_processing_timeout_minutes',
        type=int,
        default=None,
        help=(
            'The time limit (in minutes) for any PTransform to finish '
            'processing a single element. If exceeded, the SDK worker '
            'process self-terminates and processing may be restarted '
            'by a runner.'))

  def validate(self, validator):
    errors = []
    errors.extend(validator.validate_sdk_container_image_options(self))

    if validator.is_service_runner():
      errors.extend(validator.validate_num_workers(self))
      errors.extend(validator.validate_worker_region_zone(self))
    return errors


class DebugOptions(PipelineOptions):
  @classmethod
  def _add_argparse_args(cls, parser):
    parser.add_argument(
        '--dataflow_job_file',
        default=None,
        help='Debug file to write the workflow specification.')
    parser.add_argument(
        '--experiment',
        '--experiments',
        dest='experiments',
        action=_CommaSeparatedListAction,
        default=None,
        help=(
            'Runners may provide a number of experimental features that can be '
            'enabled with this flag. Please sync with the owners of the runner '
            'before enabling any experiments.'))

    parser.add_argument(
        '--number_of_worker_harness_threads',
        type=int,
        default=None,
        help=(
            'Number of threads per worker to use on the runner. If left '
            'unspecified, the runner will compute an appropriate number of '
            'threads to use.'))

  def add_experiment(self, experiment):
    # pylint: disable=access-member-before-definition
    if self.experiments is None:
      self.experiments = []
    if experiment not in self.experiments:
      self.experiments.append(experiment)

  def lookup_experiment(self, key, default=None):
    if not self.experiments:
      return default
    elif key in self.experiments:
      return True
    for experiment in self.experiments:
      if experiment.startswith(key + '='):
        return experiment.split('=', 1)[1]
    return default

  def validate(self, validator):
    errors = []
    if self.experiments:
      errors.extend(
          validator.validate_repeatable_argument_passed_as_list(
              self, 'experiments'))
    return errors


class ProfilingOptions(PipelineOptions):
  @classmethod
  def _add_argparse_args(cls, parser):
    parser.add_argument(
        '--profile_cpu',
        action='store_true',
        help='Enable work item CPU profiling.')
    parser.add_argument(
        '--profile_memory',
        action='store_true',
        help='Enable work item heap profiling.')
    parser.add_argument(
        '--profile_location',
        default=None,
        help='path for saving profiler data.')
    parser.add_argument(
        '--profile_sample_rate',
        type=float,
        default=1.0,
        help='A number between 0 and 1 indicating the ratio '
        'of bundles that should be profiled.')


class SetupOptions(PipelineOptions):
  @classmethod
  def _add_argparse_args(cls, parser):
    # Options for installing dependencies in the worker.
    parser.add_argument(
        '--requirements_file',
        default=None,
        help=(
            'Path to a requirements file containing package dependencies. '
            'Typically it is produced by a pip freeze command. More details: '
            'https://pip.pypa.io/en/latest/reference/pip_freeze.html. '
            'If used, all the packages specified will be downloaded, '
            'cached (use --requirements_cache to change default location), '
            'and then staged so that they can be automatically installed in '
            'workers during startup. The cache is refreshed as needed '
            'avoiding extra downloads for existing packages. Typically the '
            'file is named requirements.txt.'))
    parser.add_argument(
        '--requirements_cache',
        default=None,
        help=(
            'Path to a folder to cache the packages specified in '
            'the requirements file using the --requirements_file option.'
            'If you want to skip populating requirements cache, please '
            'specify --requirements_cache="skip".'))
    parser.add_argument(
        '--requirements_cache_only_sources',
        action='store_true',
        help=(
            'Enable this flag to populate requirements cache only '
            'with Source distributions(sdists) of the dependencies '
            'mentioned in the --requirements_file'
            'Note: (BEAM-4032): This flag may significantly slow down '
            'the pipeline submission. It is added to preserve the requirements'
            ' cache behavior prior to 2.37.0 and will likely be removed in '
            'future releases.'))
    parser.add_argument(
        '--setup_file',
        default=None,
        help=(
            'Path to a setup Python file containing package dependencies. If '
            'specified, the file\'s containing folder is assumed to have the '
            'structure required for a setuptools setup package. The file must '
            'be named setup.py. More details: '
            'https://pythonhosted.org/an_example_pypi_project/setuptools.html '
            'During job submission a source distribution will be built and '
            'the worker will install the resulting package before running any '
            'custom code.'))
    parser.add_argument(
        '--beam_plugin',
        '--beam_plugins',
        dest='beam_plugins',
        action='append',
        default=None,
        help=(
            'Bootstrap the python process before executing any code by '
            'importing all the plugins used in the pipeline. Please pass a '
            'comma separated list of import paths to be included. This is '
            'currently an experimental flag and provides no stability. '
            'Multiple --beam_plugin options can be specified if more than '
            'one plugin is needed.'))
    parser.add_argument(
        '--pickle_library',
        default='default',
        help=(
            'Chooses which pickle library to use. Options are dill, '
            'cloudpickle or default.'),
        choices=['cloudpickle', 'default', 'dill', 'dill_unsafe'])
    parser.add_argument(
        '--save_main_session',
        default=False,
        action='store_true',
        help=(
            'Save the main session state so that pickled functions and classes '
            'defined in __main__ (e.g. interactive session) can be unpickled. '
            'Some workflows do not need the session state if for instance all '
            'their functions/classes are defined in proper modules '
            '(not __main__) and the modules are importable in the worker. '))
    parser.add_argument(
        '--sdk_location',
        default='default',
        help=(
            'Path to a custom Beam SDK package to install and use on the'
            'runner. It can be a URL, a GCS path, or a local path to an '
            'SDK tarball. Workflow submissions will download or copy an SDK '
            'tarball from here. If set to "default", '
            'runners will use the SDK provided in the default environment.'
            'Use this flag when running pipelines with an unreleased or '
            'manually patched version of Beam SDK.'))
    parser.add_argument(
        '--extra_package',
        '--extra_packages',
        dest='extra_packages',
        action='append',
        default=None,
        help=(
            'Local path to a Python package file. The file is expected to be '
            '(1) a package tarball (".tar"), (2) a compressed package tarball '
            '(".tar.gz"), (3) a Wheel file (".whl") or (4) a compressed '
            'package zip file (".zip") which can be installed using the '
            '"pip install" command  of the standard pip package. Multiple '
            '--extra_package options can be specified if more than one '
            'package is needed. During job submission, the files will be '
            'staged in the staging area (--staging_location option) and the '
            'workers will install them in same order they were specified on '
            'the command line.'))
    parser.add_argument(
        '--files_to_stage',
        dest='files_to_stage',
        action='append',
        default=None,
        help=(
            'Local path to a file. During job submission, the files will be '
            'staged in the staging area (--staging_location option) and then '
            'workers will upload them to the worker specific staging location '
            '(e.g. $SEMI_PERSISTENT_DIRECTORY/staged/ for portable runner.'))
    parser.add_argument(
        '--prebuild_sdk_container_engine',
        help=(
            'Prebuild sdk worker container image before job submission. If '
            'enabled, SDK invokes the boot sequence in SDK worker '
            'containers to install all pipeline dependencies in the '
            'container, and uses the prebuilt image in the pipeline '
            'environment. This may speed up pipeline execution. To enable, '
            'select the Docker build engine: local_docker using '
            'locally-installed Docker or cloud_build for using Google Cloud '
            'Build (requires a GCP project with Cloud Build API enabled). You '
            'can also subclass SdkContainerImageBuilder and use that to build '
            'in other environments.'))
    parser.add_argument(
        '--prebuild_sdk_container_base_image',
        default=None,
        help=('Deprecated. Use --sdk_container_image instead.'))
    parser.add_argument(
        '--cloud_build_machine_type',
        default=None,
        help=(
            'If specified, use the machine type explicitly when prebuilding'
            'SDK container image on Google Cloud Build.'))
    parser.add_argument(
        '--docker_registry_push_url',
        default=None,
        help=(
            'Docker registry url to use for tagging and pushing the prebuilt '
            'sdk worker container image.'))
    parser.add_argument(
        '--gbek',
        default=None,
        help=(
            'When set, will replace all GroupByKey transforms in the pipeline '
            'with EncryptedGroupByKey transforms using the secret passed in '
            'the option. Beam will infer the secret type and value based on '
            'secret itself. This guarantees that any data at rest during the '
            'GBK will be encrypted. Many runners only store data at rest when '
            'performing a GBK, so this can be used to guarantee that data is '
            'not unencrypted. The secret should be a url safe base64 encoded '
            '32 byte value. To generate a secret in this format, you can use '
            'Secret.generate_secret_bytes(). For an example of this, see '
            'https://github.com/apache/beam/blob/c8df4da229da49d533491857e1bb4ab5dbf4fd37/sdks/python/apache_beam/transforms/util_test.py#L356. '  # pylint: disable=line-too-long
            'Runners with this behavior include the Dataflow, '
            'Flink, and Spark runners. The option should be '
            'structured like: '
            '--gbek=type:<secret_type>;<secret_param>:<value>, for example '
            '--gbek=type:GcpSecret;version_name:my_secret/versions/latest'))
    parser.add_argument(
        '--user_agent',
        default=None,
        help=(
            'A user agent string describing the pipeline to external services. '
            'The format should follow RFC2616.'))
    parser.add_argument(
        '--maven_repository_url',
        default=None,
        help=(
            'Custom Maven repository URL to use for downloading JAR files. '
            'If not specified, the default Maven Central repository will be '
            'used.'))

  def validate(self, validator):
    errors = []
    errors.extend(validator.validate_container_prebuilding_options(self))
    errors.extend(validator.validate_pickle_library(self))
    return errors


class PortableOptions(PipelineOptions):
  """Portable options are common options expected to be understood by most of
  the portable runners. Should generally be kept in sync with
  PortablePipelineOptions.java.
  """
  @classmethod
  def _add_argparse_args(cls, parser):
    parser.add_argument(
        '--job_endpoint',
        default=None,
        help=(
            'Job service endpoint to use. Should be in the form of host '
            'and port, e.g. localhost:8099.'))
    parser.add_argument(
        '--artifact_endpoint',
        default=None,
        help=(
            'Artifact staging endpoint to use. Should be in the form of host '
            'and port, e.g. localhost:8098. If none is specified, the '
            'artifact endpoint sent from the job server is used.'))
    parser.add_argument(
        '--job_server_timeout',
        '--job-server-timeout',  # For backwards compatibility.
        default=300,
        type=int,
        help=(
            'Job service request timeout in seconds. The timeout '
            'determines the max time the driver program will wait to '
            'get a response from the job server. NOTE: the timeout does not '
            'apply to the actual pipeline run time. The driver program can '
            'still wait for job completion indefinitely.'))
    parser.add_argument(
        '--environment_type',
        default=None,
        help=(
            'Set the default environment type for running '
            'user code. DOCKER (default) runs user code in a container. '
            'PROCESS runs user code in processes that are automatically '
            'started on each worker node. LOOPBACK runs user code on the '
            'same process that originally submitted the job.'))
    parser.add_argument(
        '--environment_config',
        default=None,
        help=(
            'Set environment configuration for running the user code.\n For '
            'DOCKER: Url for the docker image.\n For PROCESS: json of the '
            'form {"os": "<OS>", "arch": "<ARCHITECTURE>", "command": '
            '"<process to execute>", "env":{"<Environment variables 1>": '
            '"<ENV_VAL>"} }. All fields in the json are optional except '
            'command.\n\nPrefer using --environment_options instead.'))
    parser.add_argument(
        '--environment_option',
        '--environment_options',
        dest='environment_options',
        action='append',
        default=None,
        help=(
            'Environment configuration for running the user code. '
            'Recognized options depend on --environment_type.\n '
            'For DOCKER: docker_container_image (optional)\n '
            'For PROCESS: process_command (required), process_variables '
            '(optional, comma-separated)\n '
            'For EXTERNAL: external_service_address (required)'))
    parser.add_argument(
        '--sdk_worker_parallelism',
        default=1,
        help=(
            'Sets the number of sdk worker processes that will run on each '
            'worker node. Default is 1. If 0, a value will be chosen by the '
            'runner.'))
    parser.add_argument(
        '--environment_cache_millis',
        default=0,
        help=(
            'Duration in milliseconds for environment cache within a job. '
            '0 means no caching.'))
    parser.add_argument(
        '--output_executable_path',
        default=None,
        help=(
            'Create an executable jar at this path rather than running '
            'the pipeline.'))

  def validate(self, validator):
    return validator.validate_environment_options(self)

  def add_environment_option(self, option):
    # pylint: disable=access-member-before-definition
    if self.environment_options is None:
      self.environment_options = []
    if option not in self.environment_options:
      self.environment_options.append(option)

  def lookup_environment_option(self, key, default=None):
    if not self.environment_options:
      return default
    elif key in self.environment_options:
      return True
    for option in self.environment_options:
      if option.startswith(key + '='):
        return option.split('=', 1)[1]
    return default


class JobServerOptions(PipelineOptions):
  """Options for starting a Beam job server. Roughly corresponds to
  JobServerDriver.ServerConfiguration in Java.
  """
  @classmethod
  def _add_argparse_args(cls, parser):
    parser.add_argument(
        '--artifacts_dir',
        default=None,
        help='The location to store staged artifact files. '
        'Any Beam-supported file system is allowed. '
        'If unset, the local temp dir will be used.')
    parser.add_argument(
        '--job_port',
        default=0,
        type=int,
        help='Port to use for the job service. 0 to use a '
        'dynamic port.')
    parser.add_argument(
        '--artifact_port',
        default=0,
        type=int,
        help='Port to use for artifact staging. 0 to use a '
        'dynamic port.')
    parser.add_argument(
        '--expansion_port',
        default=0,
        type=int,
        help='Port to use for artifact staging. 0 to use a '
        'dynamic port.')
    parser.add_argument(
        '--job_server_java_launcher',
        default='java',
        help='The Java Application Launcher executable file to use for '
        'starting a Java job server. If unset, `java` from the '
        'environment\'s $PATH is used.')
    parser.add_argument(
        '--job_server_jvm_properties',
        '--job_server_jvm_property',
        dest='job_server_jvm_properties',
        action='append',
        default=[],
        help='JVM properties to pass to a Java job server.')
    parser.add_argument(
        '--jar_cache_dir',
        default=None,
        help='The location to store jar cache for job server.')


class FlinkRunnerOptions(PipelineOptions):

  # These should stay in sync with gradle.properties.
  PUBLISHED_FLINK_VERSIONS = ['1.17', '1.18', '1.19']

  @classmethod
  def _add_argparse_args(cls, parser):
    parser.add_argument(
        '--flink_master',
        default='[auto]',
        help='Flink master address (http://host:port)'
        ' Use "[local]" to start a local cluster'
        ' for the execution. Use "[auto]" if you'
        ' plan to either execute locally or let the'
        ' Flink job server infer the cluster address.')
    parser.add_argument(
        '--flink_version',
        default=cls.PUBLISHED_FLINK_VERSIONS[-1],
        choices=cls.PUBLISHED_FLINK_VERSIONS,
        help='Flink version to use.')
    parser.add_argument(
        '--flink_job_server_jar', help='Path or URL to a flink jobserver jar.')
    parser.add_argument(
        '--flink_submit_uber_jar',
        default=False,
        action='store_true',
        help='Create and upload an uberjar to the flink master'
        ' directly, rather than starting up a job server.'
        ' Only applies when flink_master is set to a'
        ' cluster address.  Requires Python 3.6+.')
    parser.add_argument(
        '--parallelism',
        type=int,
        default=-1,
        help='The degree of parallelism to be used when distributing'
        ' operations onto workers. If the parallelism is not set, the'
        ' configured Flink default is used, or 1 if none can be found.')
    parser.add_argument(
        '--max_parallelism',
        type=int,
        default=-1,
        help='The pipeline wide maximum degree of parallelism to be used. The'
        ' maximum parallelism specifies the upper limit for dynamic scaling'
        ' and the number of key groups used for partitioned state.')


class SparkRunnerOptions(PipelineOptions):
  @classmethod
  def _add_argparse_args(cls, parser):
    parser.add_argument(
        '--spark_master_url',
        default='local[4]',
        help='Spark master URL (spark://HOST:PORT). '
        'Use "local" (single-threaded) or "local[*]" '
        '(multi-threaded) to start a local cluster for '
        'the execution.')
    parser.add_argument(
        '--spark_job_server_jar',
        help='Path or URL to a Beam Spark job server jar. '
        'Overrides --spark_version.')
    parser.add_argument(
        '--spark_submit_uber_jar',
        default=False,
        action='store_true',
        help='Create and upload an uber jar to the Spark REST'
        ' endpoint, rather than starting up a job server.'
        ' Requires Python 3.6+.')
    parser.add_argument(
        '--spark_rest_url',
        help='URL for the Spark REST endpoint. '
        'Only required when using spark_submit_uber_jar. '
        'For example, http://hostname:6066')
    parser.add_argument(
        '--spark_version',
        default='3',
        choices=['3'],
        help='Spark major version to use.')


class PrismRunnerOptions(PipelineOptions):
  @classmethod
  def _add_argparse_args(cls, parser):
    parser.add_argument(
        '--prism_location',
        help='Path or URL to a prism binary, or zipped binary for the current '
        'platform (Operating System and Architecture). May also be an Apache '
        'Beam Github Release page URL, with a matching beam_version_override '
        'set. This option overrides all others for finding a prism binary.')
    parser.add_argument(
        '--prism_beam_version_override',
        help=
        'Override the SDK\'s version for deriving the Github Release URLs for '
        'downloading a zipped prism binary, for the current platform. If '
        'prism_location is set to a Github Release page URL, them it will use '
        'that release page as a base when constructing the download URL.')
    parser.add_argument(
        '--prism_log_level',
        default="info",
        choices=["debug", "info", "warn", "error"],
        help=(
            'Controls the log level in Prism. Values can be "debug", "info", '
            '"warn", and "error". Default log level is "info".'))
    parser.add_argument(
        '--prism_log_kind',
        default="console",
        choices=["dev", "json", "text", "console"],
        help=(
            'Controls the log format in Prism. Values can be "dev", "json", '
            '"text", and "console". Default log format is "console".'))


class TestOptions(PipelineOptions):
  @classmethod
  def _add_argparse_args(cls, parser):
    # Options for e2e test pipeline.
    parser.add_argument(
        '--on_success_matcher',
        default=None,
        help=(
            'Verify state/output of e2e test pipeline. This is pickled '
            'version of the matcher which should extends '
            'hamcrest.core.base_matcher.BaseMatcher.'))
    parser.add_argument(
        '--dry_run',
        default=False,
        help=(
            'Used in unit testing runners without submitting the '
            'actual job.'))
    parser.add_argument(
        '--wait_until_finish_duration',
        default=None,
        type=int,
        help='The time to wait (in milliseconds) for test pipeline to finish. '
        'If it is set to None, it will wait indefinitely until the job '
        'is finished.')

  def validate(self, validator):
    errors = []
    if self.view_as(TestOptions).on_success_matcher:
      errors.extend(validator.validate_test_matcher(self, 'on_success_matcher'))
    return errors


class TestDataflowOptions(PipelineOptions):
  @classmethod
  def _add_argparse_args(cls, parser):
    # This option is passed to Dataflow Runner's Pub/Sub client. The camelCase
    # style in 'dest' matches the runner's.
    parser.add_argument(
        '--pubsub_root_url',
        dest='pubsubRootUrl',
        default=None,
        help='Root URL for use with the Google Cloud Pub/Sub API.',
    )


# TODO(silviuc): Non-standard options. Keep them? If yes, add help too!
# Remote execution must check that this option is not None.


class OptionsContext(object):
  """Set default pipeline options for pipelines created in this block.

  This is particularly useful for pipelines implicitly created with the

      [python list] | PTransform

  construct.

  Can also be used as a decorator.
  """
  overrides: List[Dict[str, Any]] = []

  def __init__(self, **options):
    self.options = options

  def __enter__(self):
    self.overrides.append(self.options)

  def __exit__(self, *exn_info):
    self.overrides.pop()

  def __call__(self, f, *args, **kwargs):
    def wrapper(*args, **kwargs):
      with self:
        f(*args, **kwargs)

    return wrapper

  @classmethod
  def augment_options(cls, options):
    for override in cls.overrides:
      for name, value in override.items():
        setattr(options, name, value)
    return options


class S3Options(PipelineOptions):
  @classmethod
  def _add_argparse_args(cls, parser):
    # These options are passed to the S3 IO Client
    parser.add_argument(
        '--s3_access_key_id',
        default=None,
        help='The secret key to use when creating the s3 client.')
    parser.add_argument(
        '--s3_secret_access_key',
        default=None,
        help='The secret key to use when creating the s3 client.')
    parser.add_argument(
        '--s3_session_token',
        default=None,
        help='The session token to use when creating the s3 client.')
    parser.add_argument(
        '--s3_endpoint_url',
        default=None,
        help='The complete URL to use for the constructed s3 client.')
    parser.add_argument(
        '--s3_region_name',
        default=None,
        help='The name of the region associated with the s3 client.')
    parser.add_argument(
        '--s3_api_version',
        default=None,
        help='The API version to use with the s3 client.')
    parser.add_argument(
        '--s3_verify',
        default=None,
        help='Whether or not to verify SSL certificates with the s3 client.')
    parser.add_argument(
        '--s3_disable_ssl',
        default=False,
        action='store_true',
        help=(
            'Whether or not to use SSL with the s3 client. '
            'By default, SSL is used.'))
